Day 20,我們第一次把前面完成的元件真正組成一個 Data List:
Data List
│
├── Search
├── Result Count
├── Table
│ └── Badge
└── Pagination
畫面開始很像真正的系統:
申請紀錄
搜尋申請紀錄
[ 搜尋姓名、申請項目或狀態............ ]
共 237 筆資料
┌──────────────────────────────────────────┐
│ 姓名 申請項目 狀態 更新時間 │
├──────────────────────────────────────────┤
│ 王小明 學分抵免 [已通過] 10/01 │
│ 陳小華 休學申請 [待審核] 10/01 │
│ 林小美 獎學金申請 [已退回] 09/30 │
└──────────────────────────────────────────┘
< 1 [2] 3 4 … 10 >
但到目前為止,我們其實偷偷做了一個非常樂觀的假設:
資料一定會成功取得。
真實世界當然沒有這麼配合。
API 可能還在處理:
Loading...
可能成功取得資料,但:
0 records
也可能直接:
500 Internal Server Error
甚至還有一種情況:
原本有資料
↓
搜尋
↓
0 results
這些狀態看起來都像:
「Table 沒有資料。」
但對使用者來說,它們代表的是完全不同的事情。
所以今天要處理的是:
Data State。
今天不急著做新的大型 Component。
我們會沿用前面已經完成的:
Table
Alert
Empty State
Button
再加入:
Skeleton
最後讓 Data List 能處理:
Data List
│
├── Loading
├── Success
├── Empty
├── No Results
└── Error
並處理幾個 Accessibility 問題:
aria-busy
aria-live
role="status"
role="alert"
Loading announcement
Error feedback
Focus
Retry
在開始寫 JSX 以前,我想先把狀態畫出來。
我們現在的 Data List 其實可能處於:
Data List
│
┌────────────┼────────────┐
↓ ↓ ↓
Loading Success Error
│
┌──────┼──────┐
↓ ↓ ↓
Data Empty No Results
乍看之下:
Empty
No Results
很像。
但 Day 20 已經碰到這個問題:
資料來源本身就是空的:
applications.length === 0
例如:
目前還沒有任何申請紀錄。
原本有資料,但搜尋後沒有符合項目:
applications.length > 0
但:
filteredApplications.length === 0
例如:
找不到符合「王小明」的資料。
兩者雖然都顯示:
0 筆
但使用者下一步完全不同。
假設我搜尋:
王小明
結果系統顯示:
目前沒有資料。
我可能會理解成:
這個系統本來就沒有任何申請紀錄?
但真正的情況只是:
搜尋條件沒有符合的結果。
反過來,如果系統真的一筆資料都沒有,卻顯示:
找不到符合搜尋條件的資料。
也很奇怪。
所以:
Empty
≠
No Results
≠
Error
≠
Loading
Accessibility 不只是:
ARIA
Keyboard
Screen Reader
讓使用者知道:
現在到底發生什麼事情。
本身就是 Accessibility 的一部分。
今天第一個實作是 Loading State。
可以使用 shadcn/ui:
npx shadcn@latest add skeleton
會產生:
src/components/ui/skeleton.tsx
通常很簡單:
import { cn } from "cn"
function Skeleton({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn(
"animate-pulse rounded-md bg-accent",
className
)}
{...props}
/>
)
}
export { Skeleton }
照我們 CUI 的 Component Contract,補上:
data-cui-slot="skeleton"
最後:
<div
data-slot="skeleton"
data-cui-slot="skeleton"
className={cn(
"animate-pulse rounded-md bg-accent",
className
)}
{...props}
/>
這樣未來 Legacy 也可以:
<div data-cui-slot="skeleton"></div>
這裡有一個很重要的差別。
Skeleton:
██████████
██████
██████████████
只是:
Loading 的視覺呈現。
它本身並沒有告訴輔助科技:
資料載入中
如果只是:
<Skeleton />
<Skeleton />
<Skeleton />
Screen Reader 很可能什麼都不知道。
所以:
Skeleton
≠
Loading semantics
Skeleton 負責 Visual。
Loading State 還需要額外的 Semantic Feedback。
aria-busy如果 Data List 正在更新,可以在整個資料區域加:
<section
aria-labelledby="application-list-title"
aria-busy={isLoading}
>
當:
isLoading === true
HTML 會變成:
<section aria-busy="true">
這代表:
這個區域目前正在更新。
完成後:
<section aria-busy="false">
這非常適合:
Data List
Search Results
Dashboard Widget
Async Form
這類會動態更新內容的區域。
aria-busy 不等於 Loading Message不過:
aria-busy="true"
並不是畫面上的 Loading 文字。
所以我們還是可以提供:
<p className="sr-only">
申請紀錄載入中
</p>
甚至搭配:
role="status"
例如:
<div role="status">
<span className="sr-only">
申請紀錄載入中
</span>
<Skeleton />
<Skeleton />
<Skeleton />
</div>
這樣:
Visual
→ Skeleton
Semantic
→ 申請紀錄載入中
兩邊各自負責不同事情。
role="status" 和 role="alert" 不一樣前面做 Alert 時我們已經碰過這件事。
今天在 Async Data 裡會更明顯。
role="status"適合:
資料載入完成
搜尋完成
儲存完成
共有 10 筆資料
這類:
有用,但通常不需要立刻打斷使用者。
概念上接近:
aria-live="polite"
role="alert"適合:
載入失敗
表單送出失敗
發生重要錯誤
通常比較需要立即被注意。
所以:
Loading
→ status / aria-busy
Error
→ alert
但同樣不是死規則。
要看訊息的重要程度。
我們可以讓 Loading State 保留 Table 的大致形狀。
例如:
<Table>
<TableCaption className="sr-only">
申請紀錄載入中
</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">姓名</TableHead>
<TableHead scope="col">申請項目</TableHead>
<TableHead scope="col">狀態</TableHead>
<TableHead scope="col">更新時間</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 3 }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
畫面:
姓名 申請項目 狀態 更新時間
──────────────────────────────────────────
██████ █████████ █████ ███████
██████ █████████ █████ ███████
██████ █████████ █████ ███████
這比整張畫面突然變成:
Loading...
更能保留 Layout。
通常不用。
因為:
Skeleton 1
Skeleton 2
Skeleton 3
對使用者沒有資訊價值。
真正有價值的是:
申請紀錄載入中
因此 Skeleton 本身可以保持純視覺。
如果 Skeleton container 會產生不必要的 Accessibility Tree 內容,也可以在 Loading visual wrapper 使用:
aria-hidden="true"
例如:
<div aria-hidden="true">
<Skeleton />
<Skeleton />
<Skeleton />
</div>
但不要把真正的 Loading Message 一起藏掉。
目前還沒有真的串 API。
所以今天可以先用:
const [isLoading, setIsLoading] = useState(false)
然後暫時做一個 Demo Button:
<Button
variant="outline"
onClick={() => {
setIsLoading(true)
window.setTimeout(() => {
setIsLoading(false)
}, 2000)
}}
>
模擬載入
</Button>
這不是正式 Data List API。
只是讓我們可以真的測試:
Data
↓
Loading
↓
Data
而不是只看 Static Screenshot。
不一定。
如果是:
第一次進頁面
整個 Data List 還沒有任何資料,Search 可能暫時 Disabled。
但如果是:
使用者輸入 Search
↓
重新 Fetch
通常 Search 不應該突然消失。
可能只是:
<Input
disabled={isLoading}
/>
或甚至仍然允許輸入,再搭配 debounce / request cancellation。
這些屬於 Application Behavior。
CUI 不需要替所有產品決定。
今天先保持簡單:
Heading
Search
Loading Table
Layout 不要因 Loading 大幅跳動。
假設 API 成功:
200 OK
但回來:
[]
這不是 Error。
它只是:
目前沒有資料。
這時我們就可以使用 Day 17 做過的 Empty State。
例如:
<EmptyState>
<EmptyStateTitle>
尚無申請紀錄
</EmptyStateTitle>
<EmptyStateDescription>
目前沒有任何申請資料。
</EmptyStateDescription>
</EmptyState>
如果這個系統允許新增:
<EmptyStateAction>
<Button>
新增申請
</Button>
</EmptyStateAction>
畫面:
尚無申請紀錄
目前沒有任何申請資料。
[ 新增申請 ]
role="alert"這個很重要。
沒有資料
通常不是錯誤。
所以不要:
<EmptyState role="alert">
Screen Reader 沒必要被:
「警告!你沒有資料!」
嚇一跳 😂
Empty State 本身正常存在於 Document Flow 就可以。
如果它是 Async Loading 完成後才出現,而且真的需要通知,也可以根據情境使用比較溫和的:
status
但通常不需要硬加 ARIA。
如果:
applications.length > 0
但:
filteredApplications.length === 0
代表:
No Results
這時內容應該是:
<EmptyState>
<EmptyStateTitle>
找不到符合條件的資料
</EmptyStateTitle>
<EmptyStateDescription>
請嘗試其他搜尋關鍵字。
</EmptyStateDescription>
<EmptyStateAction>
<Button
variant="outline"
onClick={() => setQuery("")}
>
清除搜尋
</Button>
</EmptyStateAction>
</EmptyState>
所以:
Empty
→ 新增資料
No Results
→ 修改 / 清除搜尋
同一個 UI Pattern,可以服務不同狀態。
接著來到第三種:
API failed
例如:
500
Timeout
Network Error
Permission Error
這時不能顯示:
目前沒有資料
因為這會誤導使用者。
系統根本不知道:
到底有沒有資料。
它只是:
沒有成功取得資料。
因此應該明確顯示:
載入失敗
我們已經有:
<Alert>
所以 Error State 可以:
<Alert variant="destructive">
<AlertTitle>
無法載入申請紀錄
</AlertTitle>
<AlertDescription>
取得資料時發生錯誤,請稍後再試。
</AlertDescription>
</Alert>
如果你的 Alert 已經依照 Day 17 的設計,讓 role 由使用端決定,可以:
<Alert
variant="destructive"
role="alert"
>
這正好證明當初:
不把
role="alert"寫死在 Alert Component
是有價值的。
因為:
Alert visual component
≠
所有情況都是 assertive alert
假設 API 回:
ORA-00001: unique constraint ...
或者:
TypeError: Failed to fetch
通常不要直接丟給一般使用者。
UI 可以顯示:
無法載入申請紀錄
取得資料時發生錯誤,請稍後再試。
詳細技術資訊:
HTTP Status
Stack Trace
Database Error
Request ID
可以留給:
Logging
Monitoring
Developer Console
Error Tracking
使用者需要知道的是:
發生什麼事?
我現在可以做什麼?
只顯示:
載入失敗
其實幫助有限。
如果這個錯誤可以重試,就提供:
<Button
variant="outline"
onClick={handleRetry}
>
重新載入
</Button>
組合:
<Alert
variant="destructive"
role="alert"
>
<AlertTitle>
無法載入申請紀錄
</AlertTitle>
<AlertDescription>
取得資料時發生錯誤,請稍後再試。
</AlertDescription>
<AlertAction>
<Button
variant="outline"
size="sm"
onClick={handleRetry}
>
重新載入
</Button>
</AlertAction>
</Alert>
這時 Error State 就不是:
❌ Error
而是:
發生問題
+
下一步
假設使用者按:
重新載入
State 可以:
Error
↓
Loading
↓
Success
所以:
function handleRetry() {
setError(null)
setIsLoading(true)
// fetch again
}
如果今天只是 Demo:
function handleRetry() {
setError(false)
setIsLoading(true)
window.setTimeout(() => {
setIsLoading(false)
}, 2000)
}
之後真的串 API,再換成實際 fetch。
通常:
不要。
假設使用者按:
搜尋
然後:
Loading
↓
Table
如果我們突然:
tableRef.current?.focus()
使用者的 Focus 會被強行從 Search 拉走。
這通常反而很干擾。
比較好的策略:
Focus 保持原位
+
用 status / live region 告知結果更新
例如:
找到 12 筆資料
而不是:
搜尋
↓
Focus 瞬移到 Table
假設 Error State 裡:
[重新載入]
使用者按下後:
Error
↓
Loading
↓
Success
原本的 Retry Button 消失了。
這時 Focus 可能會因 DOM 消失而回到:
body
這就需要依實際產品流程判斷。
可能的策略包括:
Loading container
Result heading
Search field
但今天先不自動搬 Focus。
因為 Focus Management 要根據:
操作來源
DOM 變化
後續任務
決定。
不要看到 Dynamic UI 就:
useEffect(() => {
ref.current?.focus()
}, [state])
這種 Accessibility 魔法很容易反而害人。
如果目前只有:
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(false)
Demo 階段還可以。
但狀態越來越多:
Loading
Error
Success
Empty
就可能出現:
isLoading = true
error = true
那現在到底是哪個 State?
所以之後可以考慮:
type DataState =
| "loading"
| "success"
| "error"
例如:
const [dataState, setDataState] =
useState<DataState>("success")
然後:
if (dataState === "loading") {
...
}
if (dataState === "error") {
...
}
Empty / No Results 則可以從資料本身推導。
例如:
const [isEmpty, setIsEmpty] = useState(false)
const [hasNoResults, setHasNoResults] = useState(false)
其實不一定需要。
因為:
const isEmpty =
dataState === "success" &&
applications.length === 0
以及:
const hasNoResults =
dataState === "success" &&
applications.length > 0 &&
filteredApplications.length === 0
這些都可以從既有資料推導。
也就是:
Source State
→ loading / success / error
Derived State
→ empty / no results
避免多份 State 彼此不同步。
最後 Data List 大概可以整理成:
<section
aria-labelledby="application-list-title"
aria-busy={dataState === "loading"}
className="space-y-4"
>
<div>
<h2
id="application-list-title"
className="text-xl font-semibold"
>
申請紀錄
</h2>
<p className="text-sm text-muted-foreground">
查看目前的申請與審核狀態。
</p>
</div>
<Field>
<FieldLabel htmlFor="application-search">
搜尋申請紀錄
</FieldLabel>
<Input
id="application-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="輸入姓名、申請項目或狀態"
/>
</Field>
{dataState === "loading" && (
<div role="status">
<span className="sr-only">
申請紀錄載入中
</span>
<LoadingTable />
</div>
)}
{dataState === "error" && (
<Alert
variant="destructive"
role="alert"
>
<AlertTitle>
無法載入申請紀錄
</AlertTitle>
<AlertDescription>
取得資料時發生錯誤,請稍後再試。
</AlertDescription>
<AlertAction>
<Button
variant="outline"
size="sm"
onClick={handleRetry}
>
重新載入
</Button>
</AlertAction>
</Alert>
)}
{dataState === "success" &&
applications.length === 0 && (
<EmptyState>
<EmptyStateTitle>
尚無申請紀錄
</EmptyStateTitle>
<EmptyStateDescription>
目前沒有任何申請資料。
</EmptyStateDescription>
</EmptyState>
)}
{dataState === "success" &&
applications.length > 0 &&
filteredApplications.length === 0 && (
<EmptyState>
<EmptyStateTitle>
找不到符合條件的資料
</EmptyStateTitle>
<EmptyStateDescription>
請嘗試其他搜尋關鍵字。
</EmptyStateDescription>
<EmptyStateAction>
<Button
variant="outline"
onClick={() => setQuery("")}
>
清除搜尋
</Button>
</EmptyStateAction>
</EmptyState>
)}
{dataState === "success" &&
filteredApplications.length > 0 && (
<>
<p
aria-live="polite"
className="text-sm text-muted-foreground"
>
共 {filteredApplications.length} 筆資料
</p>
<ApplicationTable
applications={filteredApplications}
/>
<Pagination aria-label="申請紀錄分頁">
...
</Pagination>
</>
)}
</section>
現在整個 Data List 已經不再只有:
有資料
這一種世界。
如果把今天的狀態畫成流程:
┌─────────┐
│ Loading │
└────┬────┘
│
┌─────────┴─────────┐
↓ ↓
Success Error
│ │
┌──────┼──────┐ │
↓ ↓ ↓ │
Data Empty No Results │
Retry
│
└──→ Loading
這其實已經有一點:
State Machine
的味道了。
我們目前不用真的引入 State Machine Library。
但先把狀態關係想清楚,就能避免很多:
isLoading && ...
!isLoading && !error && ...
!isLoading && error && ...
data.length === 0 && ...
query && ...
最後 JSX 變成條件判斷地獄。
前面做 Input 時,我們處理:
Default
Focus
Disabled
Invalid
這些比較像:
Component State
今天處理:
Loading
Success
Empty
No Results
Error
則比較像:
Application / Data State
CUI 不需要把 Application State 全部塞進 Component。
例如我們不會做:
<Table
loading
error
empty
noResults
/>
然後讓 Table 自己決定所有東西。
而是:
Skeleton
Alert
Empty State
Table
Pagination
由 Data List Pattern 根據狀態組合。
<DataTable />?現在其實已經很容易產生這個念頭:
<DataTable
data={data}
loading={loading}
error={error}
searchable
pagination
emptyMessage="..."
onRetry={...}
/>
看起來超方便。
但很快就會長成:
<DataTable
searchable
sortable
filterable
selectable
paginated
loading
empty
error
striped
sticky
compact
responsive
...
/>
😇
所以目前 CUI 的方向還是:
Small Components
+
Clear Contracts
+
Composition Patterns
而不是:
One Component To Rule Them All
這也是今天很有趣的地方。
React:
{isLoading && <Skeleton />}
Legacy 沒有 React State。
但它一樣可以:
<section
data-cui-pattern="data-list"
aria-busy="true"
>
<div data-cui-slot="skeleton"></div>
</section>
成功後 Server Render:
<section
data-cui-pattern="data-list"
aria-busy="false"
>
<table data-cui-slot="table">
...
</table>
</section>
Error:
<div
data-cui-slot="alert"
data-variant="destructive"
role="alert"
>
...
</div>
也就是:
React
→ 用 State 決定 render 什麼
Legacy
→ Server / JavaScript 決定 render 什麼
CUI
→ 提供相同的 UI Contract
這正是我們一開始設計:
data-cui-*
的目的。
Skeleton 常見:
animate-pulse
但不是每個使用者都喜歡持續動畫。
Tailwind / CSS 可以配合:
prefers-reduced-motion
例如之後可以讓:
Reduced Motion
→ 不 pulse
或降低動畫強度。
這不是今天一定要完整實作的功能,但要記住:
Loading Animation 也是 Motion。
Accessibility 不只有:
Color
Keyboard
Screen Reader
Motion preference 也是其中一部分。
今天最後完整檢查:
✓ aria-busy
✓ 有「載入中」的文字資訊
✓ Skeleton 不承擔 Accessible Name
✓ Layout 不大幅跳動
✓ Result Count
✓ Semantic Table
✓ Badge 不只靠顏色
✓ Pagination 有清楚的 Navigation Label
✓ 明確說明目前沒有資料
✓ 不誤稱為 Error
✓ 必要時提供新增資料 Action
✓ 說明搜尋沒有結果
✓ 與真正 Empty 區分
✓ 提供清除 / 修改搜尋的下一步
✓ 明確告知載入失敗
✓ 不把 Error 說成「沒有資料」
✓ 必要時使用 role="alert"
✓ 提供 Retry
✓ 不直接暴露技術錯誤
今天 Keyboard Test 也比以前更重要。
測:
Success
Loading
Empty
No Results
Error
Retry
尤其注意:
State 改變後
Focus 有沒有突然亂跳?
正常情況:
Search
↓
Loading
↓
Result
Focus 不應該自己跑掉。
Error:
Retry Button
↓ Enter
Loading
↓ Success
也要觀察 Retry Button 消失後 Focus 的實際行為。
先記錄問題,不要看到任何 Dynamic UI 就急著用 JavaScript 強制 focus。
今天可以試著不看畫面,只想像使用者會聽到什麼。
Loading:
申請紀錄
申請紀錄載入中
Success:
共 3 筆資料
申請紀錄表格
姓名
申請項目
狀態
更新時間
...
Empty:
尚無申請紀錄
目前沒有任何申請資料
No Results:
找不到符合條件的資料
請嘗試其他搜尋關鍵字
清除搜尋,按鈕
Error:
無法載入申請紀錄
取得資料時發生錯誤,請稍後再試
重新載入,按鈕
如果只聽這些資訊,也能知道:
現在發生什麼事
+
下一步可以做什麼
那這個 State Pattern 就已經相當清楚了。
完成後:
npm run build
npm run lint
以及我們的 Contrast Check:
npm run check:contrast
確認:
git status
git diff
如果今天新增:
src/components/ui/skeleton.tsx
並修改:
src/App.tsx
可以:
git add src/components/ui/skeleton.tsx src/App.tsx
Commit:
git commit -m "feat: add data list states"
今天表面上做的是:
Loading
Empty
Error
但真正開始處理的是:
Interface State。
以前我們比較關心:
Button 能不能按?
Input 有沒有 Label?
Table Header 有沒有 scope?
Pagination 有沒有 aria-current?
現在問題變成:
資料還沒來時,使用者知道嗎?
資料真的為空時,使用者知道嗎?
搜尋不到時,使用者知道原因嗎?
API 壞掉時,使用者會不會誤以為沒有資料?
發生錯誤後,使用者知道下一步嗎?
這已經從:
Accessible Component
慢慢走向:
Accessible Experience
而今天最值得記住的是:
Loading ≠ Empty
Empty ≠ No Results
No Results ≠ Error
畫面上它們可能都只是:
「沒有 Table Rows」
但語意完全不同。
Data List Pattern
│
├── Search
│ └── Field + Input
│
├── Feedback
│ └── Result Count
│
├── Loading
│ └── Skeleton
│
├── Success
│ ├── Table
│ │ └── Badge
│ └── Pagination
│
├── Empty
│ └── Empty State
│
├── No Results
│ └── Empty State + Clear Search
│
└── Error
└── Alert + Retry
到這裡,我們已經不是單純在蒐集 UI Components 了。
我們開始建立:
一套元件應該怎麼合作的規則。
目前 CUI 已經有不少元件:
Button
Input
Field
Textarea
Checkbox
Radio
Switch
Select
Badge
Alert
Empty State
Table
Pagination
Skeleton
元件一多,下一個問題就開始出現:
為什麼這個叫 size="default"
另一個叫 size="md"?
為什麼這個用 data-invalid
另一個用 aria-invalid?
data-cui-slot 有沒有全部一致?
variant 的名稱是不是開始亂掉?
disabled / loading / invalid 到底誰負責?
如果現在不整理,再做十顆元件之後一定會開始:
「欸,我之前到底怎麼命名的?」🤣
所以 Day 22 我們先暫停新增元件,來做一次 CUI 的中期整理。
Day 22:
元件變多了:整理 CUI Component Contract
我們會正式盤點:
Naming
Variant
Size
State
ARIA
data-cui-*
React API
Legacy Contract
讓 CUI 從:
一堆做好的元件
開始真正變成:
有一致規則的 Design System。